/* eslint-disable @typescript-eslint/no-unused-vars */

import {
  ArrayExt,
  Dom,
  disposable,
  FunctionExt,
  type Nilable,
  ObjectExt,
  Util,
} from '../../common'
import { Rectangle } from '../../geometry'
import type { Graph } from '../../graph'
import type {
  Cell,
  CellBaseEventArgs,
  CellMutateOptions,
} from '../../model/cell'
import type {
  Edge,
  TerminalCellData,
  TerminalData,
  TerminalType,
} from '../../model/edge'
import type { Model } from '../../model/model'
import type { CellAttrs, SimpleAttrs } from '../../registry/attr'
import { Registry } from '../../registry/registry'
import { AttrManager, type AttrManagerUpdateOptions } from '../attr'
import { Cache } from '../cache'
import type { EdgeView } from '../edge'
import {
  FlagManager,
  type FlagManagerAction,
  type FlagManagerActions,
} from '../flag'
import { Markup, type MarkupJSONMarkup, type MarkupSelectors } from '../markup'
import type { NodeView } from '../node'
import { ToolsView, type ToolsViewOptions } from '../tool'
import type { ToolsViewUpdateOptions } from '../tool/tool-view'
import { View } from '../view'
import { createViewElement } from '../view/util'
import type {
  CellViewDefinition,
  CellViewEventArgs,
  CellViewHighlightOptions,
  CellViewInteractionNames,
  CellViewMouseEventArgs,
  CellViewMousePositionEventArgs,
  CellViewOptions,
} from './type'

export class CellView<
  Entity extends Cell = Cell,
  Options extends CellViewOptions = CellViewOptions,
> extends View<CellViewEventArgs> {
  protected static defaults: Partial<CellViewOptions> = {
    isSvgElement: true,
    rootSelector: 'root',
    priority: 0,
    bootstrap: [],
    actions: {},
  }

  public static registry = Registry.create<CellViewDefinition>({
    type: 'view',
  })

  public static getDefaults() {
    return CellView.defaults
  }

  public static isCellView(instance: any): instance is CellView {
    if (instance == null) {
      return false
    }

    if (instance instanceof CellView) {
      return true
    }

    const tag = instance[Symbol.toStringTag]
    const view = instance as CellView

    if (
      (tag == null || tag === CellViewToStringTag) &&
      typeof view.isNodeView === 'function' &&
      typeof view.isEdgeView === 'function' &&
      typeof view.confirmUpdate === 'function'
    ) {
      return true
    }

    return false
  }

  public static config<T extends CellViewOptions = CellViewOptions>(
    options: Partial<T>,
  ) {
    CellView.defaults = CellView.getOptions(options)
  }

  public static getOptions<T extends CellViewOptions = CellViewOptions>(
    options: Partial<T>,
  ): T {
    const mergeActions = <T>(arr1: T | T[], arr2?: T | T[]) => {
      if (arr2 != null) {
        return ArrayExt.uniq([
          ...(Array.isArray(arr1) ? arr1 : [arr1]),
          ...(Array.isArray(arr2) ? arr2 : [arr2]),
        ])
      }
      return Array.isArray(arr1) ? [...arr1] : [arr1]
    }

    const ret = ObjectExt.cloneDeep(CellView.getDefaults()) as T
    const { bootstrap, actions, events, documentEvents, ...others } = options

    if (bootstrap) {
      ret.bootstrap = mergeActions(ret.bootstrap, bootstrap)
    }

    if (actions) {
      Object.entries(actions).forEach(([key, val]) => {
        const raw = ret.actions[key]
        if (val && raw) {
          ret.actions[key] = mergeActions(raw, val)
        } else if (val) {
          ret.actions[key] = mergeActions(val)
        }
      })
    }

    if (events) {
      ret.events = { ...ret.events, ...events }
    }

    if (options.documentEvents) {
      ret.documentEvents = { ...ret.documentEvents, ...documentEvents }
    }

    return ObjectExt.merge(ret, others) as T
  }

  public graph: Graph
  public cell: Entity
  protected selectors: MarkupSelectors
  protected readonly options: Options
  protected readonly flag: FlagManager
  protected readonly attr: AttrManager
  protected readonly cache: Cache

  protected get [Symbol.toStringTag]() {
    return CellViewToStringTag
  }

  constructor(cell: Entity, options: Partial<Options> = {}) {
    super()

    this.cell = cell
    this.options = this.ensureOptions(options)
    this.graph = this.options.graph
    this.attr = new AttrManager(this)
    this.flag = new FlagManager(
      this,
      this.options.actions,
      this.options.bootstrap,
    )
    this.cache = new Cache(this)

    this.setContainer(this.ensureContainer())
    this.setup()

    this.init()
  }

  protected init() {}

  protected onRemove() {
    this.removeTools()
  }

  public get priority() {
    return this.options.priority
  }

  protected get rootSelector() {
    return this.options.rootSelector
  }

  protected getConstructor<T extends CellViewDefinition>() {
    return this.constructor as any as T
  }

  protected ensureOptions(options: Partial<Options>) {
    return this.getConstructor().getOptions(options) as Options
  }

  protected getContainerTagName(): string {
    return this.options.isSvgElement ? 'g' : 'div'
  }

  protected getContainerStyle():
    | Nilable<Record<string, string | number>>
    | undefined {
    return
  }

  protected getContainerAttrs(): Nilable<SimpleAttrs> {
    return {
      'data-cell-id': this.cell.id,
      'data-shape': this.cell.shape,
    }
  }

  protected getContainerClassName(): Nilable<string | string[]> {
    return this.prefixClassName('cell')
  }

  protected ensureContainer() {
    return createViewElement(
      this.getContainerTagName(),
      this.options.isSvgElement,
    )
  }

  protected setContainer(container: Element) {
    if (this.container !== container) {
      this.undelegateEvents()
      this.container = container

      if (this.options.events != null) {
        this.delegateEvents(this.options.events)
      }

      const attrs = this.getContainerAttrs()
      if (attrs != null) {
        this.setAttrs(attrs, container)
      }

      const style = this.getContainerStyle()
      if (style != null) {
        this.setStyle(style, container)
      }

      const className = this.getContainerClassName()
      if (className != null) {
        this.addClass(className, container)
      }
    }

    return this
  }

  isNodeView(): this is NodeView {
    return false
  }

  isEdgeView(): this is EdgeView {
    return false
  }

  render() {
    return this
  }

  confirmUpdate(flag: number, options: object = {}) {
    return 0
  }

  getBootstrapFlag() {
    return this.flag.getBootstrapFlag()
  }

  getFlag(actions: FlagManagerActions) {
    return this.flag.getFlag(actions)
  }

  hasAction(flag: number, actions: FlagManagerActions) {
    return this.flag.hasAction(flag, actions)
  }

  removeAction(flag: number, actions: FlagManagerActions) {
    return this.flag.removeAction(flag, actions)
  }

  handleAction(
    flag: number,
    action: FlagManagerAction,
    handle: () => void,
    additionalRemovedActions?: FlagManagerActions | null,
  ) {
    if (this.hasAction(flag, action)) {
      handle()
      const removedFlags = [action]
      if (additionalRemovedActions) {
        if (typeof additionalRemovedActions === 'string') {
          removedFlags.push(additionalRemovedActions)
        } else {
          removedFlags.push(...additionalRemovedActions)
        }
      }
      return this.removeAction(flag, removedFlags)
    }
    return flag
  }

  protected setup() {
    this.cell.on('changed', this.onCellChanged, this)
  }

  protected onCellChanged({ options }: CellBaseEventArgs['changed']) {
    this.onAttrsChange(options)
  }

  protected onAttrsChange(options: CellMutateOptions) {
    let flag = this.flag.getChangedFlag()
    if (options.updated || !flag) {
      return
    }

    if (options.dirty && this.hasAction(flag, 'update')) {
      flag |= this.getFlag('render') // eslint-disable-line no-bitwise
    }

    // tool changes should be sync render
    if (options.toolId) {
      options.async = false
    }

    if (this.graph != null) {
      this.graph.renderer.requestViewUpdate(this, flag, options)
    }
  }

  parseJSONMarkup(
    markup: MarkupJSONMarkup | MarkupJSONMarkup[],
    rootElem?: Element,
  ) {
    const result = Markup.parseJSONMarkup(markup)
    const selectors = result.selectors
    const rootSelector = this.rootSelector
    if (rootElem && rootSelector) {
      if (selectors[rootSelector]) {
        throw new Error('Invalid root selector')
      }
      selectors[rootSelector] = rootElem
    }
    return result
  }

  can(feature: CellViewInteractionNames): boolean {
    let interacting = this.graph.options.interacting

    if (typeof interacting === 'function') {
      interacting = FunctionExt.call(interacting, this.graph, this)
    }

    if (typeof interacting === 'object') {
      let val = interacting[feature]
      if (typeof val === 'function') {
        val = FunctionExt.call(val, this.graph, this)
      }
      return val !== false
    }

    if (typeof interacting === 'boolean') {
      return interacting
    }

    return false
  }

  cleanCache() {
    this.cache.clean()
    return this
  }

  getCache(elem: Element) {
    return this.cache.get(elem)
  }

  getDataOfElement(elem: Element) {
    return this.cache.getData(elem)
  }

  getMatrixOfElement(elem: Element) {
    return this.cache.getMatrix(elem)
  }

  getShapeOfElement(elem: SVGElement) {
    return this.cache.getShape(elem)
  }

  getBoundingRectOfElement(elem: Element) {
    return this.cache.getBoundingRect(elem)
  }

  getBBoxOfElement(elem: Element) {
    const rect = this.getBoundingRectOfElement(elem)
    const matrix = this.getMatrixOfElement(elem)
    const rm = this.getRootRotatedMatrix()
    const tm = this.getRootTranslatedMatrix()
    return Util.transformRectangle(rect, tm.multiply(rm).multiply(matrix))
  }

  getUnrotatedBBoxOfElement(elem: SVGElement) {
    const rect = this.getBoundingRectOfElement(elem)
    const matrix = this.getMatrixOfElement(elem)
    const tm = this.getRootTranslatedMatrix()
    return Util.transformRectangle(rect, tm.multiply(matrix))
  }

  getBBox(options: { useCellGeometry?: boolean } = {}) {
    let bbox: Rectangle
    if (options.useCellGeometry) {
      const cell = this.cell
      const angle = cell.isNode() ? cell.getAngle() : 0
      bbox = cell.getBBox().bbox(angle)
    } else {
      bbox = this.getBBoxOfElement(this.container)
    }

    return this.graph.coord.localToGraphRect(bbox)
  }

  getRootTranslatedMatrix() {
    const cell = this.cell
    const pos = cell.isNode() ? cell.getPosition() : { x: 0, y: 0 }
    return Dom.createSVGMatrix().translate(pos.x, pos.y)
  }

  getRootRotatedMatrix() {
    let matrix = Dom.createSVGMatrix()
    const cell = this.cell
    const angle = cell.isNode() ? cell.getAngle() : 0
    if (angle) {
      const bbox = cell.getBBox()
      const cx = bbox.width / 2
      const cy = bbox.height / 2
      matrix = matrix.translate(cx, cy).rotate(angle).translate(-cx, -cy)
    }
    return matrix
  }

  findMagnet(elem: Element = this.container) {
    return this.findByAttr('magnet', elem)
  }

  updateAttrs(
    rootNode: Element,
    attrs: CellAttrs,
    options: Partial<AttrManagerUpdateOptions> = {},
  ) {
    if (options.rootBBox == null) {
      options.rootBBox = new Rectangle()
    }

    if (options.selectors == null) {
      options.selectors = this.selectors
    }

    this.attr.update(rootNode, attrs, options as AttrManagerUpdateOptions)
  }

  isEdgeElement(magnet?: Element | null) {
    return this.cell.isEdge() && (magnet == null || magnet === this.container)
  }

  // #region highlight

  protected prepareHighlight(
    elem?: Element | null,
    options: CellViewHighlightOptions = {},
  ) {
    const magnet = elem || this.container
    options.partial = magnet === this.container
    return magnet
  }

  highlight(elem?: Element | null, options: CellViewHighlightOptions = {}) {
    const magnet = this.prepareHighlight(elem, options)
    this.notify('cell:highlight', {
      magnet,
      options,
      view: this,
      cell: this.cell,
    })
    if (this.isEdgeView()) {
      this.notify('edge:highlight', {
        magnet,
        options,
        view: this,
        edge: this.cell,
        cell: this.cell,
      })
    } else if (this.isNodeView()) {
      this.notify('node:highlight', {
        magnet,
        options,
        view: this,
        node: this.cell,
        cell: this.cell,
      })
    }
    return this
  }

  unhighlight(elem?: Element | null, options: CellViewHighlightOptions = {}) {
    const magnet = this.prepareHighlight(elem, options)
    this.notify('cell:unhighlight', {
      magnet,
      options,
      view: this,
      cell: this.cell,
    })
    if (this.isNodeView()) {
      this.notify('node:unhighlight', {
        magnet,
        options,
        view: this,
        node: this.cell,
        cell: this.cell,
      })
    } else if (this.isEdgeView()) {
      this.notify('edge:unhighlight', {
        magnet,
        options,
        view: this,
        edge: this.cell,
        cell: this.cell,
      })
    }
    return this
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  notifyUnhighlight(magnet: Element, options: CellViewHighlightOptions) {}

  // #endregion

  getEdgeTerminal(
    magnet: Element,
    x: number,
    y: number,
    edge: Edge,
    type: TerminalType,
  ) {
    const cell = this.cell
    const portId = this.findAttr('port', magnet)
    const selector = magnet.getAttribute('data-selector')
    const terminal: TerminalCellData = { cell: cell.id }

    if (selector != null) {
      terminal.magnet = selector
    }

    if (portId != null) {
      terminal.port = portId
      if (cell.isNode()) {
        if (!cell.hasPort(portId) && selector == null) {
          // port created via the `port` attribute (not API)
          terminal.selector = this.getSelector(magnet)
        }
      }
    } else if (selector == null && this.container !== magnet) {
      terminal.selector = this.getSelector(magnet)
    }

    return terminal
  }

  getMagnetFromEdgeTerminal(terminal: TerminalData) {
    const cell = this.cell
    const root = this.container
    const portId = (terminal as TerminalCellData).port
    let selector = terminal.magnet
    let magnet: any
    if (portId != null && cell.isNode() && cell.hasPort(portId)) {
      magnet = (this as any).findPortElem(portId, selector) || root
    } else {
      if (!selector) {
        selector = terminal.selector
      }
      if (!selector && portId != null) {
        selector = `[port="${portId}"]`
      }
      magnet = this.findOne(selector, root, this.selectors)
    }

    return magnet
  }

  // #region tools

  protected tools: ToolsView | null

  hasTools(name?: string) {
    const tools = this.tools
    if (tools == null) {
      return false
    }

    if (name == null) {
      return true
    }

    return tools.name === name
  }

  addTools(options: ToolsViewOptions | null): this
  addTools(tools: ToolsView | null): this
  addTools(config: ToolsView | ToolsViewOptions | null) {
    this.removeTools()
    if (config) {
      if (!this.can('toolsAddable')) {
        return this
      }
      const tools = ToolsView.isToolsView(config)
        ? config
        : new ToolsView(config)
      this.tools = tools
      tools.config({ view: this })
      tools.mount()
    }
    return this
  }

  updateTools(options: ToolsViewUpdateOptions = {}) {
    if (this.tools) {
      this.tools.update(options)
    }
    return this
  }

  removeTools() {
    if (this.tools) {
      this.tools.remove()
      this.tools = null
    }
    return this
  }

  hideTools() {
    if (this.tools) {
      this.tools.hide()
    }
    return this
  }

  showTools() {
    if (this.tools) {
      this.tools.show()
    }
    return this
  }

  protected renderTools() {
    const tools = this.cell.getTools()
    this.addTools(tools as ToolsViewOptions)
    return this
  }

  // #endregion

  // #region events

  notify<Key extends keyof CellViewEventArgs>(
    name: Key,
    args: CellViewEventArgs[Key],
  ): this
  notify(name: Exclude<string, keyof CellViewEventArgs>, args: any): this
  notify<Key extends keyof CellViewEventArgs>(
    name: Key,
    args: CellViewEventArgs[Key],
  ) {
    this.trigger(name, args)
    this.graph.trigger(name, args)
    return this
  }

  protected getEventArgs<E>(e: E): CellViewMouseEventArgs<E>
  protected getEventArgs<E>(
    e: E,
    x: number,
    y: number,
  ): CellViewMousePositionEventArgs<E>
  protected getEventArgs<E>(e: E, x?: number, y?: number) {
    const view = this // eslint-disable-line @typescript-eslint/no-this-alias
    const cell = view.cell
    if (x == null || y == null) {
      return { e, view, cell } as CellViewMouseEventArgs<E>
    }
    return { e, x, y, view, cell } as CellViewMousePositionEventArgs<E>
  }

  onClick(e: Dom.ClickEvent, x: number, y: number) {
    this.notify('cell:click', this.getEventArgs(e, x, y))
  }

  onDblClick(e: Dom.DoubleClickEvent, x: number, y: number) {
    this.notify('cell:dblclick', this.getEventArgs(e, x, y))
  }

  onContextMenu(e: Dom.ContextMenuEvent, x: number, y: number) {
    this.notify('cell:contextmenu', this.getEventArgs(e, x, y))
  }

  protected cachedModelForMouseEvent: Model | null

  onMouseDown(e: Dom.MouseDownEvent, x: number, y: number) {
    if (this.cell.model) {
      this.cachedModelForMouseEvent = this.cell.model
      this.cachedModelForMouseEvent.startBatch('mouse')
    }

    this.notify('cell:mousedown', this.getEventArgs(e, x, y))
  }

  onMouseUp(e: Dom.MouseUpEvent, x: number, y: number) {
    this.notify('cell:mouseup', this.getEventArgs(e, x, y))

    if (this.cachedModelForMouseEvent) {
      this.cachedModelForMouseEvent.stopBatch('mouse', { cell: this.cell })
      this.cachedModelForMouseEvent = null
    }
  }

  onMouseMove(e: Dom.MouseMoveEvent, x: number, y: number) {
    this.notify('cell:mousemove', this.getEventArgs(e, x, y))
  }

  onMouseOver(e: Dom.MouseOverEvent) {
    this.notify('cell:mouseover', this.getEventArgs(e))
  }

  onMouseOut(e: Dom.MouseOutEvent) {
    this.notify('cell:mouseout', this.getEventArgs(e))
  }

  onMouseEnter(e: Dom.MouseEnterEvent) {
    this.notify('cell:mouseenter', this.getEventArgs(e))
  }

  onMouseLeave(e: Dom.MouseLeaveEvent) {
    this.notify('cell:mouseleave', this.getEventArgs(e))
  }

  onMouseWheel(e: Dom.EventObject, x: number, y: number, delta: number) {
    this.notify('cell:mousewheel', {
      delta,
      ...this.getEventArgs(e, x, y),
    })
  }

  onCustomEvent(e: Dom.MouseDownEvent, name: string, x: number, y: number) {
    this.notify('cell:customevent', { name, ...this.getEventArgs(e, x, y) })
    this.notify(name, { ...this.getEventArgs(e, x, y) })
  }

  onMagnetMouseDown(
    e: Dom.MouseDownEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {}

  onMagnetDblClick(
    e: Dom.DoubleClickEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {}

  onMagnetContextMenu(
    e: Dom.ContextMenuEvent,
    magnet: Element,
    x: number,
    y: number,
  ) {}

  onLabelMouseDown(e: Dom.MouseDownEvent, x: number, y: number) {}

  checkMouseleave(e: Dom.EventObject) {
    const target = this.getEventTarget(e, { fromPoint: true })
    const view = this.graph.findViewByElem(target)
    if (view === this) {
      return
    }

    // Leaving the current view
    this.onMouseLeave(e as Dom.MouseLeaveEvent)
    if (!view) {
      return
    }

    // Entering another view
    view.onMouseEnter(e as Dom.MouseEnterEvent)
  }

  @disposable()
  dispose() {
    this.cell.off('changed', this.onCellChanged, this)
  }

  // #endregion
}

export const CellViewToStringTag = `X6.${CellView.name}`
